iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 4

Day 04(上):一個不准猜的 CLI — 訊號說謊、母規則與免費層的半衰期

  • 分享至 

  • xImage
  •  

系列背景:我在用 Rust 打造一套私有、自持的 AI agent 網格 spectyn-mesh——
單一 binary、CLI / TUI / App / HTTP 多介面、本地與雲端模型混編、多機聯邦。
Day 03 推導了「一份命令表、五個 renderer」的終局架構。
今天是把它逼到能用的一天:從清晨的 CLI 硬化,到深夜操作者親手串上
免費雲端 API、再順藤摸瓜治好一個潛伏的串流 bug。
這篇是正式整理版;1.4 萬字的原始流水帳(含每一次紅測與外部複檢原文)
存在 repo 的 docs/ironman-2026/day04-cli-must-not-guess.md

今天的目標用一句話說:「一個不准猜的 CLI」

不准猜使用者要什麼——求助就是求助,不准趁機做事;
不准猜狀態——退出碼要說真話,倉庫不見了不准說成倉庫是空的;
不准猜環境——資料根覆寫要全程有效,免費層的宣稱要標日期;
也不准我自己猜——每個結論都要先示範它能被推翻,才准寫進紀錄。

一整天下來,這個「不准猜」在四個地方反覆兌現:CLI 的表面行為、
測試的有效性、供應商的真實狀態、以及串流協定裡藏著的謊。

今天的成績單

戰場 開場 收場
CLI 硬化(Q1–Q3) --help 有副作用、退出碼亂報 求助唯讀、0/1/2 誠實、命名空間統一
資料根統一(#322) 16 個繞過 resolver 的呼叫點 0,棘輪測試守住不回退
供應商 failover 一輪重試 75 次、cold-start 亂寫設定 circuit breaker per-runtime,3 次封頂
selftest 69 過 / 16 掛 95 過 / 1 flake
本地 LLM(G2) 零設定 15 秒出首答
App 介面(G5) e2e 全紅(打的是三年前的 API) 15/15 綠,測今天的 API
API key 導入(G1) 沒有安全入口 set-key --key-stdin,今晚接住真實使用者
免費雲端 API 沒串過 nemotron 550B 上線當主力
串流層 空流謊稱 blank stream、過急棄家 引上游原話、同家重試,紅測釘死

貫穿全部的是一條當天定下的紀律,我叫它母規則

一、清晨的地基:讓失敗變便宜

清晨地基

一切要從一個難看的數字說起:75。修之前,一次對話裡若有供應商
掛掉,agent 的多輪迴圈會在每一輪重新支付全額重試預算——
單輪對一家死供應商接觸 75 次,使用者看到的是 TUI 卡死在轉圈,
沒有任何解釋。root cause 有兩層:斷路器是 process-global 的
假斷路(誰都能重置誰),而每輪迴圈都天真地重新走完整條鏈。

修法是把 circuit breaker 掛到 AgentRuntime 上:同一次執行
共享一個斷路器,allow() 先問、開路直接跳過、跳過的原因寫進
errors 陣列。單輪封頂 3 次接觸。配套兩件事:

  • cold-start 不寫設定:零設定啟動時,agent 條目只在記憶體
    合成 default,agents.toml 一個位元組都不動——「第一次跑」
    不該在你家留下任何你沒同意的東西。
  • 失敗訊息逐供應商列明:「All providers failed」必附每家
    原因(circuit open / 網路錯 / 401 / 模型名不存在),
    使用者才知道該修 key、換 model 名,還是通網路。
[gemini] circuit open — skipped without contact
[groq]   network error ×3, exhausted retries
[local-ollama] 200 ✓

這章沒有戲劇性,但晚上每一場戲都站在它上面:
失敗不可怕,貴才可怕。 把失敗的代價壓到一次重試,
後面才敢把免費雲端擺到鏈頭。

二、母規則:任何檢查必須先示範它會紅

母規則

規則只有一句:任何檢查,必須先示範它抓得到壞,才算數。
寫了測試?先弄壞一次程式,看它紅;紅的原因還得正確,才准修綠。
測試如此、腳本如此、監控斷言如此、我自己的口頭結論也如此。

聽起來像常識。但光是今天,它就抓到我自己三次:

  1. 指紋測試迭代自己的清單。 我寫了個測試驗證「所有命令都有指紋」,
    結果它迭代的正是產生指紋的那張表——表上少一條,測試就少驗一條,
    永遠綠。改成獨立 allowlist,兩邊對帳才有意義。
  2. 紅色示範寫在 #[cfg(test)] 之後。 我以為我看見了紅,
    其實那段程式根本沒被編譯進去。後來加了檢測儀器,
    證明「弄壞的版本真的被 cargo test 重新編譯過」,才敢宣稱紅過。
  3. if !is_read_tool(t) { continue } 清單一旦是空的,
    迴圈一次都不進,斷言全過。改成由 READ_TOOLS 清單驅動測試案例,
    空清單自己就是紅。

三次的共同點:看起來在檢查,實際上在裝飾。 我把這叫「空綠」
(vacuous green)。空綠比沒測試更毒——它給你信心,而信心是假的。

工具鏈本身也會說謊,今天收集到兩枚標本:macOS 沒有 timeout
指令,包在 pipeline 裡連退出碼都誤報成功,害 selftest 的限時
機制整段是裝飾(改成純 bash 的背景執行 + kill,並把「這台機器
沒有 timeout」寫進長期記憶);zsh 不對未加引號的變數做分詞,
一段在 bash 下正常的測試迴圈在 zsh 下悄悄變成單一參數。
selftest 從 69 過 / 16 掛修到 95 過 / 1 flake,
一半的功勞屬於「先讓它紅」。

展開講那個 timeout:selftest 需要「限時執行,逾時判掛」,
原始寫法 timeout 30 some_check 在 macOS 上直接
command not found——但因為包在 pipeline 裡,退出碼被吃成 0,
十六條檢查全數「通過」
,通過的原因是根本沒跑。純 bash 替代品
也不是一次到位:背景執行會把 stdin 掛到 /dev/null,
"$@" 0<&0 & 把呼叫端的 stdin 重新綁回去,再 export -f
讓子 shell 看得見函式。三個坑,每個都先寫一個「會紅的示範」
才敢說修好。

配套是外部複檢:每完成一項,派 codex / agy / opencode 三個
外部 AI 對抗式審查,指令是「拆掉它」,不同意處原文照登進紀錄。
今天最痛的一刀來自 codex。我原本的結論是:「拿掉 launchd plist 的
--host 0.0.0.0,那 20 條未收斂的 API 就碰不到了。」codex 的回答:

作為立即 containment 合理;作為深度防禦,是自我安慰。
agents.toml127.0.0.1 不足以證明 launchd 實際讀的是那份設定;
CLI default 永遠是 loopback;IPv6 沒有監聽 ::;
未來改 bind address 時會被阻止嗎?

最後那句是重點——我修的是「現在」,不是「以後」。照它的方案改:
只要 KNOWN_UNGATED 名單非空,程序嘗試 bind 非 loopback
就直接拒絕啟動
,把「依賴一份 plist 保持正確」換成
「執行檔自己拒絕曝露這筆債」。

$ spectyn serve --host 0.0.0.0
error: refusing to bind non-loopback while 20 API routes remain ungated

被外部複檢拆掉的結論不只這條:「13/3 分類」被 agy 判定是
粉飾性的歸類、file_read 的一條宣稱被抓到把「介面正常」與
「模型正常」焊在一起講。全部原文照登。 紀錄的價值不在
「我都對」,在「錯的地方看得見」。

三、外部複檢戰報:被拆掉的結論們

複檢戰報

母規則管「檢查是不是真的」,外部複檢管「結論是不是真的」。
規則:每完成一項,派 codex / agy / opencode 三個外部 AI
對抗式審查,指令是「拆掉它」;複檢的產出不是「通過/不通過」,
文字——同意要說為什麼,不同意要說哪裡,兩者都原文進紀錄。
今天總共被拆掉六條:

我的宣稱 複檢裁決
codex 拿掉 --host 0.0.0.0 就安全 自我安慰;改為執行檔拒絕啟動(見第二章)
codex help_flag_present 判定完備 不認 -- 分隔符;修判定+紅測
codex is_served 手寫清單可靠 手寫必漂移;改由 routes() 對帳
agy 「13 過 / 3 掛」的分類 粉飾;掛的那 3 條才是主線功能
agy dispatched 參數有用 從未被讀取的假介面;刪除
opencode file_read 驗證通過 「介面正常」與「模型正常」被焊在一起講;拆開驗

幾條值得展開。dispatched 假參數:一個函式簽名裡躺了很久的
布林,看起來像控制什麼,實際上沒有任何讀取點——這種「裝飾性介面」
比缺功能更糟,因為它讓呼叫者以為自己控制了什麼。刪掉,
編譯器替我們找出所有以為自己在用它的呼叫點。

13/3 粉飾:我把 16 項驗收寫成「13 過 / 3 掛」,語氣像及格。
agy 指出那 3 條掛的分別是 exec、repl、serve——主線中的主線;
及格的 13 條多是周邊。同一組數字,誠實的寫法是
「主線 3 條全掛,周邊 13 條通過」。數字不會說謊,
但分類會。

file_read 焊接:我宣稱「file_read 驗證通過」,證據是一次
成功的端到端呼叫。opencode 拆解:那次成功同時依賴「工具介面正確」
與「模型願意配合」,兩者只要有一個碰巧成立,測試就綠——
必須分開:介面用固定 mock 驗,模型行為用 salvage 邏輯驗。

三個外部 AI 的視角明顯互補:codex 擅長邊界與安全推理、
agy 擅長拆穿修辭、opencode 擅長分離變因。這不是儀式——
是這個專案品質最好的部分的直接來源

四、CLI 硬化三題:不准猜、不准寫、不准騙

CLI 硬化

Q1:求助永不變異。 spectyn doctor --help 先前會真的跑一輪診斷,
spectyn -q --help 會安靜地做事——求助是新手打的第一個指令,
它若有副作用,信任在第一秒就毀了。修完後任何位置的 --help/-h
都唯讀。邊角是 codex 抓的:run_tool -- --help-- 之後的
--help參數,不是求助;分辨兩者的 help_flag_present
必須尊重 -- 分隔符。配套一個母規則式的測試:
help_never_mutates 對整張命令表掃一輪 --help,
任何一條產生檔案寫入就紅——而它上線前,先示範過它抓得到
「doctor --help 會寫診斷快取」的舊行為。

Q2:退出碼誠實。 腳本作者靠 $? 分流,先前 Error:
Unknown tool[denied] 一律 exit 0——腳本眼裡全是成功。
現在:0 = 成功;1 = 執行期錯誤;2 = 用法錯誤(打錯字、未知工具、
權限被拒)。「你打錯了」跟「程式錯了」從此走不同的溝,
cron 與 CI 才有辦法對症下藥。

Q3:命名空間路由。 spectyn provider list
spectyn providers list 歷史上是兩套實作,輸出各自漂移。
現在同路由、同輸出,舊名保留相容,並在 --help 印出正名提示。

三題共用同一個地基:一張命令表(commands/mod.rs)。
CLI、TUI、HTTP、App、文件五個 renderer 都從這張表長出來;
HTTP 那側再加一條 routes() 單一來源與 is_served 對帳測試——
表上有的路由若沒被實際掛載,測試先紅。文件也不例外:
documented_scripts_are_runnable 把文件裡每一段可執行片段
真的跑一遍,文件寫錯跟程式寫錯同罪。

五、資料根統一與棘輪:16 → 0

棘輪

SPECTYN_HOME 是資料根覆寫——測試隔離、多例並行、備援還原全靠它。
但實查發現全 repo 有 16 個呼叫點繞過 resolver 直呼
dirs::home_dir():覆寫形同虛設,設了等於沒設。

這件事的起點很難堪:每日回顧看板連續 15 天顯示 events = 0
交接文件言之鑿鑿說根因是 camelCase 欄位名——是錯的
真正的根因是事件目錄不存在時,讀取函式回 Ok(vec![]):
把「倉庫不見了」說成「倉庫是空的」。修法是引入三態
StoreState { Missing, Empty, Populated },Missing 時看板直接
掛出「SUPPLY SEVERED」橫幅——寧可刺眼,不准安靜地錯
(附帶修掉一路上的計數烏龍:我先報 66、再報 24、最後才數對 16
——連「數債」這件事本身都要被複檢。)

16 個違規點全數歸零之後,留一個棘輪測試:
data_root_baseline.tsv 記錄目前違規清單(現在是空的),
任何人新增一筆直呼,測試立刻紅;數字只准變小,不准變大。
不要求英雄式一次清零,但保證債務單向遞減。

附贈一個羞恥時刻:清理過程中,某個 in-process 測試把 4 筆探針事件
寫進了操作者的真實生活資料庫——被操作者自己的日報看板抓到。
刪除、道歉、然後把「所有 in-process 測試一律 SPECTYN_HOME 隔離

  • env 鎖」寫成規範。測試污染真實資料,跟程式污染是同一種罪。

16 個違規點不是均勻散布,盤點起來有四類:設定載入(config 自己
繞過自己)、診斷與事件寫入(diag/events 各自拼路徑)、
成本帳本(costs.json 硬編 home)、排程與看板(scheduler 讀
另一套)。同一個「資料根在哪」的問題,四個子系統各答各的
——這正是 resolver 要當單一入口的理由:問題只准有一個答案,
答案只准住一個地方。
當晚 21:00 的排程回顧被現場盯著跑完:Events captured: 1,
一筆真實事件、沒有橫幅——供應鏈從斷裂到接通,前後兩天。

六、零設定本地 LLM:裝完 15 秒出首答

本地 15 秒

G2 的驗收標準寫得很俗氣:一台有 ollama 的全新機器,
裝完之後第一條指令就能開始工作。實測(全新沙盒 HOME):

$ ./install.sh
    installed: ~/.local/bin/spectyn
$ spectyn exec "Reply with exactly: FRESH-LOCAL-OK"
FRESH-LOCAL-OK        # 15 秒 · 自動偵測 ollama · 工作目錄零寫入

「零設定能用」與「零設定亂來」只有一線之隔,差別在預設值選在哪一側:
cold-start 時 trust policy 自動夾制到 Observe(唯讀),
沒有人選過的權限一律從最小開始;某個目錄要讓 agent 動手,
spectyn project trust add 明白授權。今天還把一個洩洪口堵上:
「enforcement 未設而目錄未信任」的警告,先前只在互動模式印——
腳本裡的 exec 用同一雙手寫檔,卻一聲不吭。移出互動判斷,
現在誰都躲不掉那行黃字。

同場加映:MCP server 模式今天第一次被真的測了(專案開張以來
它一直躺在功能表裡沒人打過)——stdio 握手、tools/list、
一發 file_read,通。沒測過的功能不算存在,現在它存在了。

selftest 收在 95 過 / 1 flake:那 1 條是負載相關的計時斷言,
在冷機上穩綠、在編譯併發時偶紅——它被標記為 flake 而不是
被調鬆,因為把斷言調鬆到永遠綠,就是親手製造下一個空綠
寧可紅得誠實,不要綠得心虛。

七、App 的 15/15:e2e 打的是今天的 API

App 雙路

G5 開場很難看:Playwright e2e 全紅。追下去發現紅得冤枉——
測試打的是三年前的端點(/api/statusmessage 欄位),
後端早就長成 /healthz 與 SSE 的 prompt
介面測試的天敵不是 flake,是「測著一個已經不存在的後端」還綠著;
這次它至少誠實地紅了,只是紅錯了方向。

修的是測試的世界觀,不是程式:改打 /healthz
/api/provider/list/api/life/stats/api/chat(SSE 流式斷言),
加一個 reachDashboard() 雙路 helper——Tauri 原生視窗與瀏覽器
web-shell 各走各的登入動線,測試兩邊都要會走。
相容層 tauri-compat 補上 life_stats 的 HTTP fallback
(原生 invoke 有就走原生,沒有就退 HTTP,欄位名 camelCase 與
snake_case 互轉)。另一個絆腳石藏在兩層安全機制的縫裡:
CORS 白名單(dev 埠 5173/1420)與 auth_gate 的回環豁免
必須是同一句話——一層放行、另一層 401,使用者看到的是
「隨機壞掉」。收場:15/15 綠,而且綠得有意義——
它們現在測的是今天的產品。

八、set-key 的臉:API key 永不落地

set-key

G1 要做的是「從安裝到導入 LLM API」的入口。設計先想清楚
key 會經過哪裡:argv 會進 ps 輸出與 shell 歷史;
回顯會進終端機 scrollback 與螢幕錄影;整體序列化會讓任何一個
debug endpoint 變成洩漏點。所以:

$ pbpaste | spectyn provider set-key cerebras --key-stdin --model gpt-oss-120b
cerebras: set-key ✓  [csk-…(52字元)]

$ spectyn provider set-key groq --key sk-xxx
error: --key is refused; pipe via --key-stdin, or record an env var with --env

三個永不,各自有測試守著:key 永不落 argv(明給 --key
直接拒收,連「不小心」的路都封死);永不回顯(畫面只有前綴
與長度);永不整體序列化(AgentsConfig 不實作 Serialize,
所有端點只回報 key 的「存在」與 env 變數名)。寫入走 toml_edit
原地編修——你手寫的註解、排版、順序,一個都不會動;
永不「讀進來再整份吐回去」,那條路上每一步都可能把 key 帶出門。
另一個容易忘的細節:TOML 欄位是 type 不是 provider_type
(serde rename),寫錯欄位的設定「看起來有存,讀回來是空」
——又是一種安靜的錯,靠紅測釘死。

今晚它第一次接住真實使用者:操作者本人,拿著兩把剛申請的免費 key
存在桌面的 key.txt。我從檔案把 key 餵進 stdin,全程畫面上
只出現 csk-…(52字元)。然後,免費層給我們上了一課。

九、免費層的半衰期

免費層半衰期

註冊表裡有四家「免信用卡、真免費」的供應商,六月十四日全數
實測驗證過。今晚(8/27)重演同一套流程。先把四家的現況一次攤開:

供應商 六月狀態 今晚實測 判定
Groq ✓ 驗證過 網路層 403(疑地區/線路) 換網路再試
Cerebras ✓ 驗證過 402 Payment required 免費推理已變
OpenRouter ✓ 驗證過 key 有效,註冊表模型退役 掃現役救回
Gemini ✓ 驗證過 429(當日額度盡) 明日重置

兩家變臉的細節:

Cerebras:/models 還大方列出 gemma-4-31bgpt-oss-120b,
聊天端點卻對兩個都回 402 Payment required「去帳務頁」
免信用卡註冊仍然為真——但推理已經不免費(或需要去主控台
多按一步啟用)。中間還插了一個小插曲:用 urllib 探測被 Cloudflare
1010 擋下,換瀏覽器 UA 才看到真相——同一個 API,對不同 client
說不同的話
,查證時連 UA 都是變因。

OpenRouter:key 完全有效(auth/key 回 200、usage 0),
但註冊表裡的 meta-llama/llama-3.3-70b-instruct:free
上游已退役,404。拉現役清單:17 個 :free 模型,
跟兩個月前比幾乎換了一輪。

處置不是查文件,是逐一實打:拿真 key 掃現役清單,
第一發就中——

✅ nvidia/nemotron-3-ultra-550b-a55b:free: 200 WIRED-OK

550B 的推理模型,免費。寫進設定、更新註冊表,並補上一行註解:

// :free 會流動 — default 是種子,不是承諾

同一晚的小案例還有一枚:操作者日常設定裡的 gemini 一直 404,
不是 key 壞——是 gemini-2.0-flash 這個 model 名退役了,
沒有任何公告到達使用者。改成 2.5-flash,404 變 429(額度,
明天重置)。連 401 都沒出現過:壞的從頭到尾只是一個名字。

這章的教訓:免費層的宣稱要標日期。 六月為真不等於八月為真;
註冊表寫的不是事實,是「某天曾經為真」的快照。所以 onboarding
必須原文轉述上游錯誤——使用者該看見今天的真相,
而不是我們的舊記憶。

十、訊號說謊:HTTP 200 裡躺著一具 502

訊號說謊

串上 nemotron 之後做穩定度巡檢:6 發 daemon SSE + 2 發 CLI。
全部拿到答案,但第 1 發答了一坨 JSON 亂碼——空流 fallback
掉到本地 8B 模型的傑作。把數據攤開更詭:

路徑 發數 空流 延遲
daemon SSE 6 3(集中在前兩發) 1.3–15.8s
CLI exec 2 0 4.4s / 38.5s(思考長)
累計(今晚) 9 4,全在 daemon 下一發皆恢復

第一個假設「推理模型把 max_tokens 燒完在思考上」——查證:
水位 8192,否決。第二輪直接抓現行犯:連打串流、逐 frame 統計
content 與 reasoning 字元數,第 5 發逮到:

HTTP/2 200 OK
: OPENROUTER PROCESSING
data: {"choices":[],
       "error":{"code":502,
         "message":"Upstream error from Nvidia: Service temporarily overloaded",
         "metadata":{"error_type":"provider_unavailable"}}}

運輸層說 200,流裡躺著一具 502。 choices 是空陣列,
delta 解析的每個分支都以 choices[0] 開頭——一個都不會進;
error 物件被無聲丟棄;然後判空邏輯理直氣壯地宣稱:
「model returned blank stream」。上游明明把死因寫在流裡,
我們沒讀,還替它編了一個。這是今天第三次撞見同一型失敗,
我已經有名字了:訊號說謊
(前兩案:Ok(vec![]) 把倉庫不見說成倉庫是空的;
timeout 不存在卻回報全數通過。)

log 時間線把「路徑差異」釘死——四筆 Empty stream result
全掛著 daemon 的 tag,兩發 CLI 探測一次都沒中。同一個迴圈、
同一家供應商,差在採樣運氣:免費池的抖動是陣發的,
誰在抖動窗口裡打誰中獎。這也是為什麼「重試同家一次」有效:
實測 9 發 4 空,每一次的下一發都成功

兩宗罪:謊報死因(上游原話被改編成 blank stream)、
過急棄家(共享池抖動被當成供應商死亡,一次抖動就把 550B
換成 8B,答案品質肉眼可見地掉)。

母規則走完整套。先寫兩發紅測,wiremock 灌實錄的 error frame:

  • in_stream_error_message_is_propagated——斷言 run 的錯誤訊息
    含「Service temporarily overloaded」原話。紅得漂亮:
    現行只會說 blank stream。
  • empty_errored_stream_retries_same_provider_once——mock 第一發
    吐 error frame、第二發吐正常流,單一供應商,斷言最終拿到答案。
    紅:現行一次空流就棄家。

再動四處手術,全在 agent.rs 的串流迴圈:provider 迭代改
worklist(VecDeque,同家可推回隊頭)、加 stream_error
累積器、frame 解析補讀 error 物件、判空分支改為
「引上游原話 + 同家重試一次,再空才 failover」。
紅轉綠;回歸 lib 2688 + 34 整合全綠;部署重驗 6/6 直服

加映一個尷尬的發現:追殺過程中發現串流消費迴圈竟有
兩套並行實作(agent.rs 與 streaming.rs)——而本來就有
error-frame 偵測的那套,daemon 沒在用。Day 03 才寫過
「一個核心、多個 renderer」,自家後院就趴著一對分叉。
病人已治;兩套收斂為一套,列入長期債,寫明償還條件。

十一、一鍵安裝管線的翻案

安裝管線

整理部署文件時發現三份文件對 curl | sh 各說各話:
一說「FUTURE item,未接線」;一說是主要安裝路徑,指向
spectynmesh.io;一說一行搞定。實測是第四種:

  • spectynmesh.io 根本沒有 DNS——從來就 resolve 不了
  • spectynmesh.com 上,/install.sh 回 200,382 行完整腳本,
    Cloudflare Worker 路由一直都在

管線從頭到尾是活的,活在另一個域名上。 沙盒 HOME 實測
一鍵安裝:腳本跑、平台偵測對、下載卡在 503——盤點 R2 桶:

/install.sh                 200 ✓     /install.ps1              200 ✓
/dist/spectyn-darwin-arm64  503 ✗     /dist/…-linux-x86_64      503 ✗
/dist/…-darwin-x86_64       503 ✗     /dist/…-windows-x86_64    503 ✗

兩支安裝腳本都活著,桶裡一顆 binary 都沒有。 差的最後一步是
Cloudflare 憑證,只在操作者手上;上傳指令已備好在 runbook,
wrangler login 之後一鍵即發。

沒有做的事同樣重要:120+ 個文件引用 .io,不盲掃
那是命名決策,不是 typo——本專案改名史上已經兩次證明「整片掃」
會把刻意保留的東西一起掃掉(連 sha256("phantom") 這種衍生值
都藏得過字串搜尋),這是教訓第三次生效。只修了「照著跑會卡住」
的四處,其餘列成一頁決策單等裁定。

補完最後一塊拼圖的指令已備好在 runbook,操作者登入即發:

$ cd spectynmesh-io && ./node_modules/.bin/wrangler login
$ wrangler r2 object put phantom-binaries/spectyn-darwin-arm64 \
    --file ../dist/spectyn-aarch64-apple-darwin
$ curl -s -o /dev/null -w '%{http_code}' \
    https://spectynmesh.com/dist/spectyn-darwin-arm64   # 期望 200

十二、今晚上線的供應鏈

供應鏈

$ spectyn providers priority master openrouter local-ollama gemini groq
✓ agent.master.providers = [openrouter, local-ollama, gemini, groq]

第一章的地基在這裡收割:失敗變便宜了,這條鏈才敢把
免費雲端擺第一、把重試當日常。今晚它被實戰打了三種劇本:

  • CLI exec:openrouter 直服。問它一個不存在的專案名,
    模型主動開 web_search、查不到、誠實說查不到——
    雲端模型 + 工具迴圈 + 不瞎掰,一次到位。
  • daemon SSE 第 1 發:openrouter 空流 → failover 接住,
    使用者仍拿到答案(那次 8B 的亂答,正是第八章整場獵殺的起點)。
  • 修復部署後:6/6 直服;空流從此先同家重試,再空才輪到本地。

韌性不是沒有失敗,是失敗的那一發使用者也拿到正確答案——
而且 log 說得出為什麼。

十三、G4:只有操作者能勾的那一格

G4

G1 到 G5 的佇列裡,G4 寫著「操作者複測」。自動化今天交出了
它能交的一切:89 項 golden master 全綠、selftest 95/1、
e2e 15/15、六項外部複檢、以及一份 10 條指令的複測腳本。
那一格我不能勾

自動化能證明「它會動」;只有使用者能證明「它好用」——
輸出看不看得懂、錯誤訊息知不知道下一步、體感延遲能不能接受,
這三個問題的答案不在任何斷言裡,在操作者的手上。
10 條指令、約五分鐘:--help 讀一遍、doctor 跑一遍、
provider list 對一遍、exec 問一句、退出碼驗一顆……
腳本存在 plan/G4-操作者複測腳本.md,格式是「指令 + 你該看到什麼

  • 沒看到就回報什麼」。

系統的最後一個測試永遠是人。這一格空著,day04 就還沒有真正結束
——這不是謙虛,是驗收定義。

附錄:這一天是怎麼被記下來的

流水帳的規則跟程式的規則同一套:邊做邊寫、當場寫、寫難看的
每一段都在事件發生的十分鐘內落盤;複檢的反對意見原文照登;
數錯的數字(66→24→16)不塗改、追加更正。一天下來
1.4 萬字、80+ 個 commit 推上 dev 分支,紀錄與程式在同一個
repo 裡同一批 commit——紀錄本身也走版本控制,也吃母規則
(文件裡的可執行片段有測試真的跑它們)。

工程之外的兩枚長期記憶也值得記:macOS 沒有 timeout
(撞了三次才學會寫進記憶);「改名債不可整片掃」
(第三次生效,這次擋下的是 120+ 處 .io)。
好的教訓不是記在腦裡,是記在下次一定會被再看到的地方

收尾:今天帶走的四句話

  1. 不會紅的檢查不是檢查,是裝飾。(母規則;今天抓到自己三次)
  2. 免費層的宣稱要標日期。(六月為真 ≠ 八月為真;default 是種子不是承諾)
  3. 200 不是平安,流裡的 error 物件才是死因。(判空之前,先問流有沒有話要說)
  4. 債務要上棘輪。(16→0 之後,新增違規自動紅;數字只准變小)

還欠一件事:操作者本人的 G4 複測——10 條指令的親手驗收,
只有操作者能勾那一格。自動化能證明「它會動」,
只有使用者能證明「它好用」。

明天(Day 05):review gate——兩個 AI 互相對審的合流閘門。
完整流水帳(1.4 萬字,含每一次紅測、每一句外部複檢、
與被拆掉的結論原文)在 repo:
docs/ironman-2026/day04-cli-must-not-guess.md


下半部:當日實錄精選(原文)

完整流水帳有 1.4 萬字,超過平台單篇 64KB 上限——以下按「當天
最重要的現場」優先收錄原文段落,一字未改;完整版永存 repo:
docs/ironman-2026/day04-cli-must-not-guess.md

前言

Day 03 把「一份命令表,五個 renderer」推導完,也走完了 provider 那一組的 0→5 步。
今天要處理的是同一棵樹上更難堪的一件事:這個 CLI 會猜。

你打 spectyn capture --help,它不會告訴你沒有這個命令,
它會把整串字丟給語言模型,然後印出一段幻覺出來的專案文件。

一次 API 呼叫。你只是想看說明。


Q1 · 兩道防線,和它們中間的那個洞

這棵樹上已經有兩道防線,都是我前幾天才加的:

防線 擋什麼
DeclaredButUndispatched 名字在指令表裡,但沒有任何處理常式接它 —— 那是我自己的派工缺陷
Typo 編輯距離夠近的近似字,例如 serveeserve

capture 兩道都不中。它不在指令表裡(真正的命令是 spectyn event capture,有命名空間),
編輯距離也不夠近。於是它穿過兩道防線,變成一句提示送進模型。

這不是漏了一個字,是這種擋法本身有問題。 兩道防線都依賴一份要有人記得更新的名單。
名單漏掉的下一個是誰,你事前不會知道——capture 就是那個「下一個」。

所以我加的第三條刻意與名單無關

argv 裡出現 --help-h,而那個詞沒有被任何處理常式接走 → 直接拒絕,exit 2。

不管那個詞是已知指令、打錯的字,還是完全的亂碼。要說明的人不是在問問題,
沒有人會寫一句以 --help 結尾的提示

一個順序上的決定

spectyn servee --help 同時命中 typo 和 help 兩條。哪一條該贏?

我讓 typo 贏。「你是不是要打 serve?」比「這不是指令」有用——使用者要的是下一步,
不是分類。所以 help 那條放在最後當兜底:上面每一條都靠名單,只有它不靠。

驗收

不是「我覺得修好了」,是數字:43 個宣告的子命令逐個加 --help 跑一遍,
燒掉模型呼叫的次數 = 0


然後我自己示範了一次「綠燈是假的」

單元測試四條全綠。我拿真的執行檔一跑:

$ spectyn capture --help
◆ capture --help
本檔案描述了 Spectyn Mesh 的架構和工具的詳細設計……

還在燒。

我當下的第一句話是「我的修正沒有生效」。錯了——壞掉的是我的驗證腳本:

for c in "capture --help" ...; do
  ./spectyn $c        # ← zsh 不對未加引號的變數做斷詞
done

bash 會把 $c 拆成兩個參數,zsh 不會。所以真正跑的是
spectyn "capture --help" —— 一個參數,那確實是一句提示,模型該接它。

改成 ${=c}(zsh 的顯式斷詞)之後全部正確。

這件事值得留著:我差點根據一個壞掉的驗證,去改一段其實正確的程式碼。
測試綠、真機紅的時候,先懷疑量測。


真正的收穫在別的地方:golden master 自己有病

Q1 的出口條件之一是「golden master 全綠」。跑下去紅了兩項,而我那天根本沒碰那些路徑:

< ✓ version: spectyn 0.6.0 (GITHASH, macos-aarch64, built 2026-08-26)
> ✓ version: spectyn 0.6.0 (GITHASH, macos-aarch64, built 2026-08-27)

< ✓ Life Node: 尚未擷取任何事件 — 試試 `spectyn note "<文字>"`
> (doctor --json 多出一個 key: life_node.by_kind.text)

第一項是建置日期,跨日就紅——天生會變的東西沒被遮蔽。小事。

第二項才是問題。 前一晚我為了驗證另一件事,用 spectyn note 寫了一筆真的筆記。
於是 golden 紅了。

也就是說:這支釘住「行為有沒有變」的腳本,跑在我的活資料上。
「我昨天用了這個工具」跟「程式行為變了」在它眼裡長得一模一樣。

特徵化測試釘的是程式的行為,不是這台機器今天的狀態。這條我寫在腳本開頭,
自己卻沒做到。

修法,以及第一次沒修對

第一版我用 mktemp -d 開一個一次性資料根。結果還是紅:

< ✓ events log: /var/folders/.../tmp.v7QtmwGBO5/.spectyn-mesh/events.jsonl
> ✓ events log: GOLDENHOME/.spectyn-mesh/events.jsonl

--update 把當次的隨機路徑烤進了快照,而比對時遮的是「這一次」的隨機路徑。
兩個不同的隨機值,永遠對不上。

改成固定路徑,再把 repo 根遮成 REPO(快照要能跨機器重現)。連跑三次:

✓ golden-cli:89 項全部相符
✓ golden-cli:89 項全部相符
✓ golden-cli:89 項全部相符

重錄快照是一次刻意的決定

釘住資料根之後,舊快照(在有資料的根上錄的)全部要重錄。
腳本自己寫著「行為真的要改的話,跑 --update 並在同一個 commit 說明為什麼」——
所以理由寫進 commit 訊息:

新快照釘的是「乾淨機器上的行為」。 那正是冷啟動要看的那一面,
比釘住我個人的資料狀態有價值得多。

而 selftest 仍然證明它會紅:汙染任何一張快照,比對照樣失敗。


這一段的規律

今天到目前為止,三件事是同一個形狀:

看起來 實際上
capture --help CLI 在回答你 它在猜,而且收費
我的驗證腳本 修正沒生效 量測壞了
golden master 行為變了 是我用了那個工具

三次都是「訊號本身在說謊」。 而昨天 S0 修的那條——回顧把「儲存區不見了」
印成「今天沒事發生」——是同一個病的第四個實例。

我開始覺得這不是一串巧合,是這個專案當前最主要的失效模式。


派 codex 複檢,結果它抓到我兩個洞

規矩是每做完一項就派一家外部 AI 複檢。第一次派失敗了——腳本給了 codex
--dangerously-bypass-approvals-and-sandbox,它就真的去執行 spectyn
觸發了 onboarding,輸出被 hook 日誌洗掉。

複檢只需要讀 diff,不需要執行權限。改成 --sandbox read-only 之後拿到了有用的東西。

它抓到的第一個:我的規則沒有尊重 --

-- 是 POSIX 的慣例:它之後的東西全是資料,不是選項。所以這句應該是一個問題,不是求助:

$ spectyn -- compare -h

我的 help_flag_present 掃整個 argv,於是把它拒絕了。

而我那條「防止這件事」的測試,是綠的。

因為我挑的例子是 spectyn -- "compare -h and --help"——加了引號,
整句變成一個 argv token,--help 從來沒有獨立存在過。規則根本沒被觸發。

它不是通過了,它是沒有被考到。

改成未加引號之後立刻紅,修完再綠。

它抓到的第二個,比較難堪

我有一條測試長這樣:

#[test]
fn dispatched_commands_handle_their_own_help() {
    assert_eq!(refuse_implicit_prompt("serve", true, true), None);
}

看起來在證明「spectyn serve --help 不歸這條規則管」。

codex 指出:正式呼叫點永遠傳 dispatched: false

match refuse_implicit_prompt(word, false, help_requested) {

只有那條測試自己傳 true。它測的是一個生產環境不存在的輸入

我今天整篇文章在講「訊號說謊」,然後在自己剛寫的測試裡放了一個。

刪掉了,原地留註解說明為什麼,並把主張改成用真執行檔驗證:

core/tests/cli_help_never_costs_a_model_call.rs
  ✓ 非指令的 --help → 退出 2 且無模型呼叫
  ✓ 已派工的指令 → 印自己的說明、退出 0
  ✓ `--` 之後是資料
  ✓ 43 個子命令全掃 → 燒錢 0 次

最後那條把我手動跑的清掃變成可重複的測試。手動跑一次證明的是今天;
測試證明的是每一次。

它的第三點我沒採納

codex 說 spectyn compare -h and --help(未加引號的多詞提示)會被誤擋。

沒採納,理由是:parser 本來就只取第一個 bareword 當提示
所以那個形式在我改動之前就已經是有損的——它會拿 compare 一個字去問模型。
而現在的錯誤訊息會指向 -c,那才是送多詞提示的正確方式。

把一個本來就壞的形式「修復」成能運作,是擴大而不是收斂。

這次複檢的價值

兩個洞我自己都不會發現。第一個是因為我寫測試時挑了對自己有利的例子,
第二個是因為那條測試看起來就很像在做事。

外部複檢不是禮貌流程,它是唯一能看穿「我以為我測了」的東西。


Q2 · 同一個能力,五個名字

Day 03 把 provider 那一組走完了 0→5 步。今天要證明那不是一次性的巧合,
所以再收兩組。

第一組:生活軌道

先盤點,逐個從程式碼數出來——這是同一個能力:

spectyn event capture / event show     ← event 命名空間
spectyn note / recall / review          ← 頂層散落
spectyn data delete / export / stats    ← data 命名空間

八個命令、一個能力、三個不相干的命名空間。 而 HTTP 只有 /api/events 一條。

沒有人能回答「生活軌道能做什麼」,除非去讀 CLI 的 parser。

這一組還有另一層意義:它就是我昨天 S0 修好的那條供料線。
讓 app 搆得到它,才會從「我可以去終端機做」變成「我會做」。

六個 handler,零套新實作

這是這張表最重要的一條紀律:HTTP handler 只能呼叫 CLI 用的同一個函式。

life.stats  → data_cli::compute_stats
life.review → daily_review_wire::load_daily_review
life.recall → recall::search_events
life.show   → data_cli::resolve_event_id + search_events
life.note   → note_capture::capture_note
life.capture→ note_capture::capture_text_event

最後一個需要一點手術。capture_notekind 寫死成 "note"
life.capture 要能帶種類。我沒有另外寫一個——
把它抽成 capture_text_event(dir, kind, text, tags)
capture_note 變成 kind = "note" 的那個呼叫。

多一套「事件怎麼落到磁碟」的實作,正是這張表要防的東西。

兩個刻意的空格

life.exportlife.delete 我沒有開 HTTP,理由寫在宣告的正上方:

serve0.0.0.0:7878(launchd 的設定檔就寫死 --host 0.0.0.0
lsof 實測 *:7878 LISTEN),而 /api/* 不需要任何憑證。
匯出端點在那上面,等於一次請求就把整份生活紀錄交給同網段的任何人。

/api/events 已經在一頁一頁地漏,那是它自己的待辦——
但**「門已經半開」不是在旁邊加裝卸貨區的理由。**

life.delete --all 更直接:無憑證的遠端抹除。等單機可信那一關過了再說。


第二組:mesh,反過來的病

providerlife 都是 CLI 多、HTTP 少。mesh 剛好相反:

HTTP /api/mesh/api/mesh/activity/api/mesh/run/api/mesh/failover/api/mesh/stress/api/mesh/dev/rpc/peers
CLI clusternodespeerdoctor --meshswarm —— 五個不相干的頂層名字

同一個能力,五個名字,兩邊都不知道對方存在。

宣告成一組之後,「同一件事」才看起來像一件事,而不是五個意外。

這一組的線畫在哪

五個唯讀端點開,兩個不開:

  • mesh.ping 讓 daemon 對呼叫者指定的 URL 發出站請求。
    無憑證又綁在所有網卡上的話,這是一個 SSRF 原語——
    任何人都能用我的機器,去探測他自己碰不到的主機。
  • mesh.assign 派真的 agent 工作到別台機器,花的是我的模型預算。
    無憑證端點上,那是一顆遠端花錢按鈕

/api/mesh/run 已經是同樣的風險。但那是待辦,不是再加一個的許可。


然後我自己做出了一個「null 看起來像資料」

mesh.status 第一版我這樣寫:

"node": crate::node_manifest::resolve_node("").map(|n| n.name),

resolve_node依名稱查找。傳空字串當然找不到。所以每一台機器都回:

{"caps":[],"known_nodes":1,"node":null,"peer_count":0}

它回 200。它通過了全部九條表測試。

因為 null 在 JSON 裡看起來就像「這個欄位沒有資料」,而不是「我壞了」。

我今天寫了一整篇在講這件事,然後在自己剛寫的 handler 裡做了一個。

修法不只是改那一行。我把選擇邏輯抽成 local_node_summary(nodes)
讓它可以被測,並且讓「找不到本機節點」回傳一個帶 error 欄位的物件
而不是 null——因為那是故障,不是空欄位。

配兩條守衛,實測先紅後綠:把 find(|n| n.is_local) 改回錯的判斷,測試立刻失敗。

驗收

不是「表看起來對」,是端到端打真的:

16 條 canonical GET 路徑 → 全部 200

這條是 Day 03 學到的:那天 provider.healthis_served() 說有,
canonical 路徑實際回 404,因為只註冊了 legacy 的複數路徑。
表說有,不代表路由存在。


雙閘複檢:兩家沒看過對方的答案,指出同一件事

Q2 我改用 ask.sh review——agy 和 opencode 並行,兩份獨立的回答。

兩家都說:is_served 那個名單本身就是手動維護的旗標。

我的 http.rs 開頭寫著:

缺口由「有沒有 handler」決定,不由任何需要人手更新的欄位決定 ——
手動維護的旗標會像它要取代的那四個手寫表面一樣漂走。

然後我在同一個檔案裡寫了:

pub fn is_served(name: &str) -> bool {
    matches!(name, "provider.list" | "provider.health" | ... )   // 手寫名單
}

pub fn register(router) -> Router {
    router.route(&canonical_path("provider.list"), get(list))     // 另一份手寫名單
          ...
}

兩份清單,描述同一個事實,沒有任何東西讓它們保持一致。

我在消滅手動旗標的同時,造了一個。

改法是讓 routes() 成為唯一真相:register 走它,is_served 問它。

連帶的一個好證據

這個改動讓另一條測試自然失效了——every_served_name_gets_a_canonical_route
include_str! 扒自己的原始碼,找 canonical_path("名字") 的字面。
register 改成 fold 之後那些字面消失了,測試紅了,而行為是正確的。

opencode 在複檢裡正好預測了這件事:「如果 register() 的呼叫改成迴圈或宏,這個測試就壞了。」

它壞得剛剛好。舊的主張現在由建構方式保證,所以我把守衛換成仍然會咬人的反向:
沒有宣告的路由不准存在宣告了 HTTP 的命令不准沒有路由


agy 指出我的安全線畫得不一致

我擋掉了 mesh.pingmesh.assign,理由是「對外動作」。

agy 說:那 mesh.nodesmesh.peers 呢?

{"nodes":[{"hostname":"...","os":"macos","arch":"aarch64","version":"0.6.0","base_url":"..."}]}

在一個綁 0.0.0.0、不要憑證的 daemon 上,這是一張帶指紋的叢集地圖——
哪些 CVE 適用、下一扇門在哪。

我擋的是「動作」,開的是「偵察」。線畫錯了。

修法不是把整組關掉(app 真的需要顯示叢集),而是在 HTTP 投影層去掉指紋欄位
身分與健康留下,OS/架構/版本/位址拿掉。完整細節留在沒有綁 0.0.0.0 的原生面。


而我在修這個的時候,又做了一個空綠守衛

去指紋要配一條守衛,不然下次有人覺得「儀表板顯示版本比較好看」就加回去了。

我寫的第一版:

for f in FINGERPRINT_FIELDS {
    assert!(v.get(*f).is_none(), "`{f}` 洩漏到 LAN 了");
}

依母規則,我去證明它會紅——把 "version"FINGERPRINT_FIELDS 拿掉。

測試通過了。

因為那條測試遍歷的就是 FINGERPRINT_FIELDS 本身。把欄位從清單拿掉,
檢查也一起消失了。檢查跟被檢查的是同一份清單。

改成白名單——「LAN 投影只准出現這些鍵」——再試一次:

`version` reached the LAN surface and is not on the allowlist

這次會紅了。

這是今天第三次。而這一次是母規則自己抓到的——如果我沒有堅持
「每條守衛都要示範它會紅」,那條空的測試會一直在那裡,
而且它看起來非常像在保護我。


還弄壞過一次

重寫 register 的時候,我順手把 legacy 的複數路徑也註冊進來:

let r = r.route("/api/providers/health", get(health));

原本的註解寫著「Register the canonical path too」——意思是複數路徑
已經由 serve.rs 註冊了,這裡只補 canonical。

我讀反了。結果:

thread 'main' panicked at axum/routing/path_router.rs:70:
Overlapping method route. Handler for `GET /api/providers/health` already exists

daemon 開機即死,56 個建 router 的測試連帶倒。

修完之後回到 2673 passed / 2 failed,端到端 16 條路徑全部 200。

一個註解裡的「too」,代價是一次啟動崩潰。


然後我把整個 /api 的曝光量出來

修完自己的洞,順手問一個我一直沒問的問題:其他的呢?

沒有任何東西列得出「哪些端點有閘門」,因為那是每個 handler 各自的性質。
所以我寫了一支——用非 loopback 位址,逐條打真的 router:

=== /api auth inventory ===
routes probed               : 30
gated (401/403)             : 10
answered without credentials: 20

二十條。 而且不是無害的清單:

/api/todos        /api/brief       /api/sessions
/api/governance/log               /api/cost
/api/activity     /api/projects    /api/rank

待辦、每日簡報、對話 session、治理稽核軌跡、花費。
同網段任何裝置可讀,不需要任何憑證。

我沒有一次修完二十條

理由寫在檔案裡:其中幾條(/api/version/api/status)可能是節點探索與
監控的承重牆。直接上閘門可能弄壞艦隊,而這裡沒有任何測試會發現

那是一個決定,不是順手。

所以我做今天安全且有用的那一半——棘輪

/// 2026-08-27 量到的曝光。這份清單只准變短,不准變長。
/// 名字出現在這裡不是背書,是一筆有日期的債。
const KNOWN_UNGATED: &[&str] = &[ ... 20 條 ... ];

加入第 21 條會紅。而修好其中一條卻沒把它從清單刪掉,也會紅——
所以這個基準不會在債還完之後悄悄過期。

兩個方向我都實測過會紅。


派 codex 複檢 Q3,它一句話拆了我的結論

我的結論是:「拿掉 plist 的 --host 0.0.0.0,那 20 條就碰不到了。」

codex 的回答:

作為立即 containment 合理;作為深度防禦,是自我安慰。

拿掉 launchd 的 --host 0.0.0.0 只是在依賴另一層設定繼續保持正確。
agents.toml127.0.0.1 不足以證明:launchd 實際讀的是那份設定;
CLI default 永遠是 loopback;IPv6 沒有監聽 ::;沒有其他 listener、
proxy、tunnel 或設定 override;未來改 bind address 時會被阻止。

最後那句是重點。我修的是「現在」,不是「以後」。

它給的替代方案很直接:只要 KNOWN_UNGATED 非空,程序若嘗試 bind 非 loopback
就直接拒絕啟動。

我照做了。這把「依賴一份 plist 保持正確」換成「執行檔自己拒絕曝露這筆債」:

$ spectyn serve --host 0.0.0.0
spectyn serve: refusing to bind ["0.0.0.0"]: 20 /api routes still answer a
remote caller with no credentials, so binding off loopback would publish them.
  Fix the routes, or set SPECTYN_ALLOW_UNGATED_LAN=1 to accept the exposure
  deliberately.

那份債的清單因此從測試檔搬進了正式碼——它現在是程式看得見的事實,不是文件。

幾個刻意的細節

  • 覆寫只認精確的 "1"。service unit 裡誤寫成 =0=false,一律 fail closed。
  • 主機名解析不出來時不假設是 loopback。往寬鬆的方向猜,守衛就變裝飾。
  • 沿用既有的 SPECTYN_ALLOW_EMPTY_CLUSTER_SECRET 姿態,不發明新慣例。

而我第一版放錯位置

我把檢查放在 bind_http_listener 旁邊。看起來很合理——就在綁定之前嘛。

實際跑起來:

  WebSocket : ws://0.0.0.0:7923/ws          ← 先印了
  Health    : http://0.0.0.0:7923/healthz
spectyn serve: refusing to bind ["0.0.0.0"] ← 才拒絕

而且更早的地方已經發出了 mDNS 廣播。節點先在區網宣告自己,然後才拒絕綁定——
廣告一個死端點,這棵樹在別的地方就警告過這個失效模式。

移到 --host 解析完的下一行,任何廣播與輸出之前。


它也抓到我又寫了一條空綠測試

我為了釘住「Observe 仍然可用」寫的那條:

for t in ["file_read", "grep", "list_dir"] {
    if !is_read_tool(t) { continue; }   // 「不是 read tool 就跳過」
    assert!(...);
}

那個 continue 我寫的時候覺得是防禦性的——萬一某個名字在這個 build
裡不是 read tool,跳過就好,不要因為猜錯名字而誤報。

複檢說:三個名稱若全都不是真的工具名,這個迴圈零斷言通過

它是對的。而且更難堪的是——這正是同一個檔案裡的守衛要防的形狀

改成用真的 READ_TOOLS 清單驅動,並斷言清單非空。
deny 那半也先斷言那些名字確實不是 read tool,
否則「被擋」只是「不認得」。

今天第四次「我為了讓檢查更穩健而加的東西,讓它變成空的」。
前三次:附加在 #[cfg(test)] 之後、去指紋守衛遍歷自己的清單、
紅示範沒進到執行檔。

規律很清楚:防禦性的 continueskipunwrap_or(true)
是空綠最常見的入口。


「裝得起來」——第一道門就是壞的

出口條件有兩半:拿到第一個回應(做完了),和裝得起來

README 的第一個指令:

$ ./install.sh
zsh: permission denied: ./install.sh

那個檔是 mode 0644新使用者的第一個動作,是一個 permission error。

執行位不花任何成本,git 也追蹤它。擋住這件事的只有「有沒有人注意到」。

範圍我刻意收窄

倉庫裡有 96 個有 shebang 卻沒有執行位的 .sh

但多數是別的腳本用 bash foo.sh 叫的,mode 完全不影響。
如果我全部要求執行位,那條檢查會每天對 90 幾個無害的檔案叫,
然後被靜音——跟不存在一樣。

所以守衛只管一件事:文件叫使用者用 ./ 跑的腳本,必須跑得動。

掃 README 和 docs/ 之後,真正壞掉的是 4 個:

install.sh · build-linux.sh · setup-oci.sh · tests/e2e/run_tier1.sh

四個。不是九十六個。

一條會對正常狀況叫的檢查,跟一條不會叫的檢查,最後效果一樣。

兩個方向都防空綠

  • 掃不到足夠的 ./x.sh 引用 → 紅。那代表掃描器壞了,不是倉庫乾淨。
  • 對不上足夠的真實檔案 → 紅。

G2–G5:本地模型、app 端,與三個只有截圖才看得見的缺陷

G2(本地 LLM,release 版):全新根零設定,Reply with exactly: 三連發
3/3 正確、零寫入、7 秒級。S0→S2 的鏈在正式安裝的執行檔上成立。

G5(app 端)最曲折。重寫 app 的 Playwright e2e 對準現行系統,
一路挖出三個「只有真的去看畫面才會發現」的缺陷:

  1. e2e 的 daemon 探測打 /health,daemon 答的是 /healthz——
    整組 daemon 測試在每台機器上永遠 skip,組內斷言還是上古方言
    (/tools/task、Bearer)。探測一修,化石當場現形。
    今天第四個「探測壞掉→整組從沒被測」。

  2. dashboard 的生活紀錄卡寫著 Unknown command: life_stats——
    web 模式的 tauri-compat 不認識這個指令,而 daemon 明明今天早上
    就有了 /api/life/stats。補 HTTP fallback(鍵名 camelCase↔snake 映射),
    並把「那張卡不得再出現這個錯誤」寫成斷言。

  3. 同一個請求,CORS 說可以、gate 說不行——開了
    SPECTYN_CORS_ALLOW_LOCALHOST 後,CORS 允許 5173,auth gate 卻因
    跨源 Origin 剝奪 loopback 豁免回 403,卡上只剩 Failed to fetch
    一個開關必須是一個政策:gate 在同一 env 下放行同一組 dev origins,
    預設行為逐 byte 不變。

修完:app e2e 15/15 全綠——provider 清單(含「不得回傳 key 材料」)、
life stats 真資料上卡、mesh status、chat 一輪(SSE)、dashboard 可達
(自檢的 proceed 按鈕文案隨結果變:「跳過,強制」vs「繼續」,測試認兩種)。

21:00,排程回顧自己跑了

# Daily review — 2026-08-27
**Events captured:** 1
### s0 (1)
- S0 驗收:封存舊資料根、修好空轉哨兵、重建事件儲存區

21:00 準時、引用真實事件、沒有 SUPPLY SEVERED、沒有 tool-call JSON。
S0 那條「隔天早上的回顧是根據真實事件寫的」——今晚提前驗收了。

昨天這個時間,同一個排程寫的是 events=0,而且沒有人知道。


晚間收尾:push、三個「一個一個弄完」,和一個域名懸案

指令變成「先 push,接著一個一個弄完」。push 上去 84 個 commit,然後逐項:

修好日常設定的 gemini。 一行:gemini-2.0-flashgemini-2.5-flash
(備份先行)。修後 404 消失、換成 429 —— 名字對了,免費額度今天用完,
明天自動恢復。連 401 都沒有,key 一直是有效的——壞的只是一個
沒有人告訴你已經退役的 model 名。

exec 也要警告 silent-off。 驗證途中,一發走日常鏈的 probe 讓本機模型
真的把檔案寫進了工作目錄(enforcement=off 是你裁定保留的現況)。政策不動;
但那句「沒有人選過這個」的警告先前只在互動啟動印——腳本裡的 exec
用同一雙手寫檔,卻一聲不吭
。移出互動判斷,現在都印。

然後是 curl|sh 的大翻案。 兩處文件寫著它是「FUTURE item,未接線」,
runbook 卻把它列為主要安裝路徑,指向 spectynmesh.io。實測:

  • .io 根本無法解析——沒有 DNS
  • .com 上,/install.sh 回 200,382 行完整安裝腳本,Worker 路由一直都在

管線從頭到尾是活的,活在另一個域名上。 「未接線」的宣稱、指錯的域名、
和一個從沒被打過的 URL,三份文件各說各話,而真相是第四種。

沙盒 HOME 實測一鍵安裝:腳本跑、平台偵測對、下載卡在 503——
R2 桶裡一顆 binary 都沒有(五個平台全缺,兩支安裝腳本倒是都活著)。
差的最後一步是 Cloudflare 憑證,只在操作者手上。上傳指令已備好在
PUBLISHING-BINARIES,登入即發。

沒有做的: 120+ 個文件把 .io 當正式域名。這不是 typo 是決策,
不盲掃(改名債的教訓第三次派上用場)——只修了照著跑會卡住的四處。


深夜加映:操作者親手串一把免費雲端 API

G4 還沒跑,操作者先出了另一道更真實的題:「直接運作安裝,然後我找一個
免費的 LLM API 直接串起來。」這正是 G1-G5 整條鏈的實戰首演——
而且劇本不是我寫的。

安裝:真機跑 ./install.sh,binary 進 ~/.local/bin,原設定完好。
15 秒後零設定 exec 經 ollama 正常回答——路 A 無話可說。

拿 key:我開了 Cerebras 和 OpenRouter 的註冊頁,操作者自己登入、
自己建 key(帳密永遠不經過我),存成桌面一個 key.txt。我從檔案把 key
餵進 set-key --key-stdin——G1 做的那張臉第一次接住真實使用者:
key 全程不落 argv、不回顯,我只看得到 csk-…(52字元)

然後免費層給我們上了一課。

  • Cerebras:/models 顯示兩個模型,聊天端點卻對兩個都回
    402 Payment required。六月才驗證過的「免信用卡免費層」,八月已
    變成要去主控台按過帳務頁。註冊表宣稱的半衰期,比想像中短。
  • OpenRouter:key 完全有效(auth/key 200),但註冊表裡的
    llama-3.3-70b-instruct:free 上游已退役(404)。拉現役清單:
    17 個 :free 模型,跟兩個月前幾乎換了一輪。

掃描:拿真 key 逐一打現役 :free 清單,第一發就中——
nvidia/nemotron-3-ultra-550b-a55b:free,550B 的推理模型,精確回了
WIRED-OK。寫進設定,重排 failover:openrouter → local-ollama → gemini → groq

三發驗收,三種結局,全部正確:

  1. CLI exec:openrouter 直接服務,模型還主動 web_search 查了
    spectyn-mesh、誠實說查不到——雲端模型 + 工具迴圈 + 不瞎掰,一次到位
  2. daemon SSE 第一發:openrouter 回空流,agent 印一行 WARN、
    自動退到本地模型,使用者拿到正確答案——免費池打嗝,failover 接住,
    這正是它存在的意義
  3. daemon SSE 再兩發:openrouter 直接服務,思考流 + 正確答案,
    log 零 fallback

修掉的:註冊表兩處過期宣稱(cerebras 402 註記、openrouter 換
現役模型 + 「:free 會流動,default 是種子不是承諾」)。8 測試綠,
重建部署三處。

一句話總結今晚:接線的每一步都活著,會死的是免費層的承諾。
所以 set-key 的臉、failover 的鏈、和誠實的錯誤訊息,才是真正的產品。


「開始查吧」:一次穩定度巡檢挖出的訊號謊言

串好雲端 API 後,操作者一聲令下開查。6 發 SSE + 2 發 CLI:全部有答案,
第 1 發答了一坨 JSON——空流 fallback 掉到 8B 本地模型的傑作。
統計:今天累計 4 次「Empty stream result」,全在 daemon 路徑,CLI 零次

第一個假設:推理模型把 max_tokens 燒完在思考上。查證:水位 8192,否決。
第二輪:直接抓現行犯——連打串流統計 content/reasoning 字元,第 5 發逮到:

HTTP 200
: OPENROUTER PROCESSING
data: {"choices":[], "error":{"code":502,
      "message":"Upstream error from Nvidia: Service temporarily overloaded",
      "metadata":{"error_type":"provider_unavailable"}}}

HTTP 說 200,流裡卻是一具 502。 choices 是空陣列,所以 delta 解析
一個分支都不會進;錯誤物件被無聲丟棄;然後判空分支宣稱
「model returned blank stream」。上游明明把死因寫在流裡,我們沒讀,
還替它編了一個。這是 day04 第三次撞見同一型失敗:訊號說謊

第二宗罪:一次瞬時打嗝就把 550B 的健康供應商整個放棄,掉到下一家
(往往更弱)。實測 9 發裡 4 次空流、下一發即恢復——這是共享池抖動,
不是供應商死亡。

母規則,紅測先行,兩發:

  1. in_stream_error_message_is_propagated — mock 吐實錄的 error frame,
    斷言錯誤訊息含上游原話。紅得漂亮:現行只會說 blank stream 謊言
  2. empty_errored_stream_retries_same_provider_once — 第一發 error frame、
    第二發正常流,單一供應商,斷言最終拿到答案。紅:現行直接棄家

手術四處(全在 agent.rs 串流迴圈):迭代器改 worklist(VecDeque,
可把同家推回隊頭)、stream_error 累積器、frame 解析補讀 error 物件、
判空分支改為「引上游原話 + 同家重試一次再 failover」。

紅轉綠,回歸 lib 2688 + 34 整合全綠,部署重驗 6/6 直服。

加映的反轉:追殺過程發現串流消費迴圈有兩套並行實作
(agent.rs 與 streaming.rs)。day03 才寫「一個核心多個 renderer」,
自家後院就趴著一對分叉——而且 streaming.rs 那套本來就有
error-frame 偵測,daemon 卻走沒有的那套。病人已治;
兩套收斂為一套,列入長期債。

教訓濃縮:200 不是平安,流裡的 error 物件才是死因;
判空之前,先問流有沒有話要說。


(以上為精選;完整版見 repo。)


上一篇
Day03 CLI 要怎麼併進 app?我的答案是不要併
下一篇
Day 04(下):實錄全文 — 紅測、複檢裁決與被拆掉的結論
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言